简单概述
我们选择开源项目,通常会比较关注这个项目的测试用例编写的是否完善,一个优秀项目的测试一般写的不会差。为了日后自己能写出一个好的项目,测试这块还是要好好学习下。
常接触的测试主要是单元测试和性能测试。毫无意外,go 的 testing 也支持这两种测试。单元测试用于模块测试,而性能则是由基准测试完成,即 benchmark。
Go 测试模块除了上面提到的功能,还有一项能力,支持编写案例,通过与 godoc 的结合,可以非常快捷地生成库文档。
最易想到的方法
谈到如何测试一个函数的功能,对开发来说,最容易想到的方法就是在 main 中直接调用函数判断结果。
举个例子,测试 math 方法下的绝对值函数 Abs,示例代码如下:
package main
import (
"fmt"
"math"
)
func main() {
v := math.Abs(-10)
if v != 10 {
fmt.Println("测试失败")
return
}
fmt.Println("测试成功")
}
复制代码
更常见的可能是,if 判断都没有,直接 Print 输出结果,我们观察结果确认问题。特别对于习惯使用 Python、PHP 脚本语言的开发, 建一个脚本测试是非常快速的,因为曾经很长一段时间,我就是如此。
这种方式有什么缺点?我的理解,主要几点,如main 中的测试不容易复用,常常是建了就删;测试用例变多时,灵活性不够,常会有修改代码的需求;自动化测试也不是非常方便等等问题。
我对测试的了解不是很深,上面这些仅仅我的一些体验吧。
遇到了问题就得解决,下面正式开始进入 go testing 中单元测试的介绍。
一个快速体验案例
单元测试用于在指定场景下,测试功能模块在指定的输入情况下,确定有没有按期望结果输出结果。
我们直接看个例子,简单直观。测试 math 下的 Abs 绝对值函数。首先,在某个目录创建测试文件 math_test.go,代码如下:
package math
import (
"math"
"testing"
)
func TestAbs(t *testing.T) {
var a, expect float64 = -10, 10
actual := math.Abs(a)
if actual != expect {
t.Fatalf("a = %f, actual = %f, expected = %f", a, actual, expect)
}
}
复制代码
程序非常简洁,a 是 Abs 函数的输入参数,expect 是期望得到的执行结果,actual 是函数执行的实际结果,测试结果由 actual 和 expect 比较结果确定。
完成用例编写,go test 命令执行测试,我们会看到如下输出。
$ go test
PASS
ok study/test/math 0.004s
复制代码
输出为 PASS,表示测试用例成功执行。0.004s 表示用例执行时间。
学会使用 go testing
从前面例子中可以了解到,Go 的测试写起来还是非常方便的。关于它的使用方式,主要有两点,一是测试代码的编写规则,二是 API 的使用。
测试的编写规则
Go 的测试必须按规则方式编写,不然 go test 将无法正确定位测试代码的位置,主要三点规则。
首先,测试代码文件的命名必须是以 _test.go 结尾,比如上节中的文件名 math_tesh.go 并非随意取的。
还有,代码中的用例函数必须满足匹配 TestXxx,比如 TestAbs。
关于 Xxx,简单解释一下,它主要传达两点含义,一是 Xxx 表示首个字符必须大写或数字,简单而言就是可确定单词分隔,二是首字母后的字符可以是任意 Go 关键词合法字符,如大小写字母、下划线、数字。
第三,关于用例函数类型定义,定义如下。
func TestXxx(*testing.T)
复制代码
测试函数必须按这个固定格式编写,否则 go test 将执行报错。函数中有一个输入参数 t, 类型是 *testing.T,它非常重要,单元测试需通过它反馈测试结果,具体后面再介绍。
灵活记忆 API 的使用
按规则编写测试用例只能保证 go test 的正确定位执行。但为了可以分析测试结果,我们还需要与测试框架进行交互,这就需要测试函数输入参数 t 的参与了。
在 TestAbs 中,我们用到了 t.Fatalf,它的作用就是反馈测试结果。假设没有这段代码,发生错误也会反馈测试成功,这显然不是我们想要的。
我们可以通过官方文档,看下 testing.T 中支持的可导出方法,如下:
// 获取测试名称
method (*T) Name() string
// 打印日志
method (*T) Log(args ...interface{})
// 打印日志,支持 Printf 格式化打印
method (*T) Logf(format string, args ...interface{})
// 反馈测试失败,但不退出测试,继续执行
method (*T) Fail()
// 反馈测试成功,立刻退出 测试
method (*T) FailNow()
// 反馈测试失败,打印错误
method (*T) Error(args ...interface{})
// 反馈测试失败,打印错误,支持 Printf 的格式化规则
method (*T) Errorf(format string, args ...interface{})
// 检测是否已经发生过错误
method (*T) Failed() bool
// 相当于 Error + FailNow,表示这是非常严重的错误,打印信息结束需立刻退出。
method (*T) Fatal(args ...interface{})
// 相当于 Errorf + FailNow,与 Fatal 类似,区别在于支持 Printf 格式化打印信息;
method (*T) Fatalf(format string, args ...interface{})
// 跳出测试,从调用 SkipNow 退出,如果之前有错误依然提示测试报错
method (*T) SkipNow()
// 相当于 Log 和 SkipNow 的组合
method (*T) Skip(args ...interface{})
// 与Skip,相当于 Logf 和 SkipNow 的组合,区别在于支持 Printf 格式化打印
method (*T) Skipf(format string, args ...interface{})
// 用于标记调用函数为 helper 函数,打印文件信息或日志,不会追溯该函数。
method (*T) Helper()
// 标记测试函数可并行执行,这个并行执行仅仅指的是与其他测试函数并行,相同测试不会并行。
method (*T) Parallel()
// 可用于执行子测试
method (*T) Run(name string, f func(t *T)) bool
复制代码
上面列出了单元测试 testing.T 中所有的公开方法,我个人思路,把它们大概分为三类,分别是底层方法、测试反馈,还有一些其他运行控制的辅助方法。
基础信息的 API 只有 1 个,Name() 方法,用于获取测试名称。运行控制的辅助方法主要指的是 Helper、t.Parallel 和 Run,上面的注释对它们已经做了简单介绍。
我们这里重点说说测试反馈的 API,毕竟它用的最多。前面用到的 Fatalf 方法就是其中之一,它的效果是打印错误日志并立刻退出测试。希望速记这类 API 吗?我们或许可以按几个层级进行记忆。
首先,我们记住一些相关的基础方法,它们是其它方法的核心组成,如下:
- 日志打印,Log 与 Logf,Log 和 Logf 区别可对比 Println 和 Printf,即 Logf 支持 Printf 格式化打印,而 Log 不支持。
- 失败标记,Fail 和 FailNow,Fail 与 FailNow 都是用于标记测试失败的方法,它们的区别在于 Fail 标记失败后还会继续执行执行接下来的测试,而 FailNow 在标记失败后会立刻退出。
- 测试忽略,SkipNow 方法退出测试,但并不会标记测试失败,可与 FailNow 对比记忆。
我们再看看剩余的那些方法,基本都是由基础方法组合而来。我们可根据场景,选择不同的组合。比如:
- 普通日志,只是打印一些日志,可以直接使用 Log 或 Logf 即可;
- 普通错误,如果不退出测试,只是打印一些错误提示信息,使用 Error 或 Errorf,这两个方法是 log 或 logf 和 Fail 的组合;
- 严重错误,需要退出测试,并打印一些错误提示信息,使用 Fatal (log + FailNow) 或 Fatalf (logf + FailNow);
- 忽略错误,并退出测试,可以使用 Skip (log + SkipNow) 和 Skipf (logf + SkipNow);
如果支持 Printf 的格式化信息打印,方法后面都会有 一个 f 字符。如此一总结,我们发现 testing.T 中的方法的记忆非常简单。
突然想到,不知是否有人会问什么情况下算是测试成功。其实,只要没有标记失败,测试就是成功的。
实践一个案例
讲了那么多基础知识,我都有点口感舌燥了。现在,开始尝试使用一下它吧!
举一个简单的例子,测试一个除法函数。首先,创建一个 math.go 文件。函数代码如下:
package math
import "errors"
func Division(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
复制代码
Division 非常简单,输入参数 a、b 分别是被除数和除数,输出参数是计算结果和错误提示。如果除数是 0,将会给出相应的错误提示。
在正式测试 Division 函数前,我们先要梳理下什么样的输入与期望结果表示测试成功。输入不同,期望结果也就不同,可能是正确结果,亦或者是期待的错误结果。什么意思?以这里的 Division 为例,两种场景需要考虑:
- 正常调用返回结果,比如当被除数为 10,除数为 5,期望得到的结果为 2,即期望得到正确的结果;
- 期望错误返回结果,当被除数为 10,除数为 0,期望返回除数不能为 0 的错误,即期望返回错误提示;
如果是测试驱动开发,在我们正式写实现代码前,就需要把这些先定义好,并且写好测试代码。
分析完用例就可以开始写代码啦。
先是正常调用的测试,如下:
func TestDivision(t *testing.T) {
var a, b, expect float64 = 10, 5, 2
actual, err := Division(a, b)
if err != nil {
t.Errorf("a = %f, b = %f, expect = %f, err %v", a, b, expect, err)
return
}
if actual != expect {
t.Errorf("a = %f, b = %f, expect = %f, actual = %f", a, b, expect, actual)
}
}
复制代码
定义了三个变量,分别是 a、b、expect,对应被除数、除数和期望结果。用例通过对比 Division 的实际结果 actual 与期望结果 expect 确认测试是否成功。还有就是,Division 返回的 error 也要检查,因为这里期待的正常运行结果,只要有错即可认定测试失败。
再看期望错误结果,如下:
func TestDivisionZero(t *testing.T) {
var a, b float64 = 10, 0
var expectedErrString = "division by zero"
_, err := Division(a, b)
if err.Error() != expectedErrString {
t.Errorf("a = %f, b = %f, err %v, expect err %s", a, b, err, expectedErrString)
return
}
}
复制代码
同样是首先定义了三个变量,a、b 和 expectErrString,a、b 含义与之前相同,expectErrString 为预期提示的错误信息。除数 b 设置为 0 ,主要是为了测试 Division 函数是否能按预期返回错误,所以我们并不关心计算结果。测试成功与否,通过比较实际的返回 error 与 expectErrString 确定。
通过 go test 执行测试,如下:
$ go test -v
=== RUN TestDivision
--- PASS: TestDivision (0.00s)
=== RUN TestDivisionZero
--- PASS: TestDivisionZero (0.00s)
PASS
ok study/test/math 0.005s
复制代码
结果显示,测试成功!
这个案例的演示中,我们在 go test 上加入 -v 选项,这样就可以清晰地看到每个测试用例的执行情况。
简洁紧凑的表组测试
通过上面的例子,不知道有没有发现一个问题?
如果将要测试的某个功能函数的用例非常多,我们将会需要写很多代码重复度非常高的测试函数,因为对于单元测试而言,基本都是围绕一个简单模式:
指定输入参数 -> 调用要测试的函数 -> 获取返回结果 -> 比较实际返回与期望结果 -> 确认测试失败提示
基于此,Go 提倡我们使用一种称为 "Table Driven" 的测试方式,中文翻译,可称为表组测试。它可以让我们以一种短小紧密的方式编写测试。具体如何做呢?
首先,我们要定义一个用于表组测试的结构体,其中要包含测试所需的输入与期望的输出。以 Division 函数测试为例,可以定义如下的结构体:
type DivisionTable struct {
a float64 // 被除数
b float64 // 除数
expect float64 // 期待计算值
expectErr error // 期待错误字符串
}
复制代码
各字段的含义在注释部分已经做了相关说明,和我们之前做的单个场景的测试涉及字段差不多。区别在于 expectErr 不再是 string 类型。